/*
 * Copyright (C) 2011-2015 René Jeschke <rene_jeschke@yahoo.de>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.github.rjeschke.txtmark;

import java.util.ArrayList;
import java.util.HashMap;

/**
 * Emitter class responsible for generating HTML output.
 *
 * @author René Jeschke <rene_jeschke@yahoo.de>
 */
class Emitter {
    /**
     * Link references.
     */
    private final HashMap<String, LinkRef> linkRefs = new HashMap<String, LinkRef>();
    /**
     * The configuration.
     */
    private final Configuration config;
    /**
     * Extension flag.
     */
    public boolean useExtensions = false;

    /**
     * Constructor.
     */
    public Emitter(final Configuration config) {
        this.config = config;
        this.useExtensions = config.forceExtendedProfile;
    }

    /**
     * Adds a LinkRef to this set of LinkRefs.
     *
     * @param key     The key/id.
     * @param linkRef The LinkRef.
     */
    public void addLinkRef(final String key, final LinkRef linkRef) {
        this.linkRefs.put(key.toLowerCase(), linkRef);
    }

    /**
     * Transforms the given block recursively into HTML.
     *
     * @param out  The StringBuilder to write to.
     * @param root The Block to process.
     */
    public void emit(final StringBuilder out, final Block root) {
        root.removeSurroundingEmptyLines();

        switch (root.type) {
            case RULER:
                this.config.decorator.horizontalRuler(out);
                return;
            case NONE:
            case XML:
                break;
            case HEADLINE:
                this.config.decorator.openHeadline(out, root.hlDepth);
                if (this.useExtensions && root.id != null) {
                    out.append(" id=\"");
                    Utils.appendCode(out, root.id, 0, root.id.length());
                    out.append('"');
                }
                out.append('>');
                break;
            case PARAGRAPH:
                this.config.decorator.openParagraph(out);
                break;
            case CODE:
            case FENCED_CODE:
                if (this.config.codeBlockEmitter == null) {
                    this.config.decorator.openCodeBlock(out);
                }
                break;
            case BLOCKQUOTE:
                this.config.decorator.openBlockquote(out);
                break;
            case UNORDERED_LIST:
                this.config.decorator.openUnorderedList(out);
                break;
            case ORDERED_LIST:
                this.config.decorator.openOrderedList(out);
                break;
            case LIST_ITEM:
                this.config.decorator.openListItem(out);
                if (this.useExtensions && root.id != null) {
                    out.append(" id=\"");
                    Utils.appendCode(out, root.id, 0, root.id.length());
                    out.append('"');
                }
                out.append('>');
                break;
        }

        if (root.hasLines()) {
            this.emitLines(out, root);
        } else {
            Block block = root.blocks;
            while (block != null) {
                this.emit(out, block);
                block = block.next;
            }
        }

        switch (root.type) {
            case RULER:
            case NONE:
            case XML:
                break;
            case HEADLINE:
                this.config.decorator.closeHeadline(out, root.hlDepth);
                break;
            case PARAGRAPH:
                this.config.decorator.closeParagraph(out);
                break;
            case CODE:
            case FENCED_CODE:
                if (this.config.codeBlockEmitter == null) {
                    this.config.decorator.closeCodeBlock(out);
                }
                break;
            case BLOCKQUOTE:
                this.config.decorator.closeBlockquote(out);
                break;
            case UNORDERED_LIST:
                this.config.decorator.closeUnorderedList(out);
                break;
            case ORDERED_LIST:
                this.config.decorator.closeOrderedList(out);
                break;
            case LIST_ITEM:
                this.config.decorator.closeListItem(out);
                break;
        }
    }

    /**
     * Transforms lines into HTML.
     *
     * @param out   The StringBuilder to write to.
     * @param block The Block to process.
     */
    private void emitLines(final StringBuilder out, final Block block) {
        switch (block.type) {
            case CODE:
                this.emitCodeLines(out, block.lines, block.meta, true);
                break;
            case FENCED_CODE:
                this.emitCodeLines(out, block.lines, block.meta, false);
                break;
            case XML:
                this.emitRawLines(out, block.lines);
                break;
            default:
                this.emitMarkedLines(out, block.lines);
                break;
        }
    }

    /**
     * Finds the position of the given Token in the given String.
     *
     * @param in    The String to search on.
     * @param start The starting character position.
     * @param token The token to find.
     * @return The position of the token or -1 if none could be found.
     */
    private int findToken(final String in, final int start, final MarkToken token) {
        int pos = start;
        while (pos < in.length()) {
            if (this.getToken(in, pos) == token) {
                return pos;
            }
            pos++;
        }
        return -1;
    }

    /**
     * Checks if there is a valid markdown link definition.
     *
     * @param out   The StringBuilder containing the generated output.
     * @param in    Input String.
     * @param start Starting position.
     * @param token Either LINK or IMAGE.
     * @return The new position or -1 if there is no valid markdown link.
     */
    private int checkLink(final StringBuilder out, final String in, final int start, final MarkToken token) {
        boolean isAbbrev = false;
        int pos = start + (token == MarkToken.LINK ? 1 : 2);
        final StringBuilder temp = new StringBuilder();

        temp.setLength(0);
        pos = Utils.readMdLinkId(temp, in, pos);
        if (pos < start) {
            return -1;
        }

        final String name = temp.toString();
        String link = null, comment = null;
        final int oldPos = pos++;
        pos = Utils.skipSpaces(in, pos);
        if (pos < start) {
            final LinkRef lr = this.linkRefs.get(name.toLowerCase());
            if (lr != null) {
                isAbbrev = lr.isAbbrev;
                link = lr.link;
                comment = lr.title;
                pos = oldPos;
            } else {
                return -1;
            }
        } else if (in.charAt(pos) == '(') {
            pos++;
            pos = Utils.skipSpaces(in, pos);
            if (pos < start) {
                return -1;
            }
            temp.setLength(0);
            final boolean useLt = in.charAt(pos) == '<';
            pos = useLt ? Utils.readUntil(temp, in, pos + 1, '>') : Utils.readMdLink(temp, in, pos);
            if (pos < start) {
                return -1;
            }
            if (useLt) {
                pos++;
            }
            link = temp.toString();

            if (in.charAt(pos) == ' ') {
                pos = Utils.skipSpaces(in, pos);
                if (pos > start && in.charAt(pos) == '"') {
                    pos++;
                    temp.setLength(0);
                    pos = Utils.readUntil(temp, in, pos, '"');
                    if (pos < start) {
                        return -1;
                    }
                    comment = temp.toString();
                    pos++;
                    pos = Utils.skipSpaces(in, pos);
                    if (pos == -1) {
                        return -1;
                    }
                }
            }
            if (in.charAt(pos) != ')') {
                return -1;
            }
        } else if (in.charAt(pos) == '[') {
            pos++;
            temp.setLength(0);
            pos = Utils.readRawUntil(temp, in, pos, ']');
            if (pos < start) {
                return -1;
            }
            final String id = temp.length() > 0 ? temp.toString() : name;
            final LinkRef lr = this.linkRefs.get(id.toLowerCase());
            if (lr != null) {
                link = lr.link;
                comment = lr.title;
            }
        } else {
            final LinkRef lr = this.linkRefs.get(name.toLowerCase());
            if (lr != null) {
                isAbbrev = lr.isAbbrev;
                link = lr.link;
                comment = lr.title;
                pos = oldPos;
            } else {
                return -1;
            }
        }

        if (link == null) {
            return -1;
        }

        if (token == MarkToken.LINK) {
            if (isAbbrev && comment != null) {
                if (!this.useExtensions) {
                    return -1;
                }
                out.append("<abbr title=\"");
                Utils.appendValue(out, comment, 0, comment.length());
                out.append("\">");
                this.recursiveEmitLine(out, name, 0, MarkToken.NONE);
                out.append("</abbr>");
            } else {
                this.config.decorator.openLink(out);
                out.append(" href=\"");
                Utils.appendValue(out, link, 0, link.length());
                out.append('"');
                if (comment != null) {
                    out.append(" title=\"");
                    Utils.appendValue(out, comment, 0, comment.length());
                    out.append('"');
                }
                out.append('>');
                this.recursiveEmitLine(out, name, 0, MarkToken.NONE);
                this.config.decorator.closeLink(out);
            }
        } else {
            this.config.decorator.openImage(out);
            out.append(" src=\"");
            Utils.appendValue(out, link, 0, link.length());
            out.append("\" alt=\"");
            Utils.appendValue(out, name, 0, name.length());
            out.append('"');
            if (comment != null) {
                out.append(" title=\"");
                Utils.appendValue(out, comment, 0, comment.length());
                out.append('"');
            }
            this.config.decorator.closeImage(out);
        }

        return pos;
    }

    /**
     * Check if there is a valid HTML tag here. This method also transforms auto
     * links and mailto auto links.
     *
     * @param out   The StringBuilder to write to.
     * @param in    Input String.
     * @param start Starting position.
     * @return The new position or -1 if nothing valid has been found.
     */
    private int checkHtml(final StringBuilder out, final String in, final int start) {
        final StringBuilder temp = new StringBuilder();
        int pos;

        // Check for auto links
        temp.setLength(0);
        pos = Utils.readUntil(temp, in, start + 1, ':', ' ', '>', '\n');
        if (pos != -1 && in.charAt(pos) == ':' && HTML.isLinkPrefix(temp.toString())) {
            pos = Utils.readUntil(temp, in, pos, '>');
            if (pos != -1) {
                final String link = temp.toString();
                this.config.decorator.openLink(out);
                out.append(" href=\"");
                Utils.appendValue(out, link, 0, link.length());
                out.append("\">");
                Utils.appendValue(out, link, 0, link.length());
                this.config.decorator.closeLink(out);
                return pos;
            }
        }

        // Check for mailto auto link
        temp.setLength(0);
        pos = Utils.readUntil(temp, in, start + 1, '@', ' ', '>', '\n');
        if (pos != -1 && in.charAt(pos) == '@') {
            pos = Utils.readUntil(temp, in, pos, '>');
            if (pos != -1) {
                final String link = temp.toString();
                this.config.decorator.openLink(out);
                out.append(" href=\"");
                Utils.appendMailto(out, "mailto:", 0, 7);
                Utils.appendMailto(out, link, 0, link.length());
                out.append("\">");
                Utils.appendMailto(out, link, 0, link.length());
                this.config.decorator.closeLink(out);
                return pos;
            }
        }

        // Check for inline html
        if (start + 2 < in.length()) {
            temp.setLength(0);
            return Utils.readXML(out, in, start, this.config.safeMode);
        }

        return -1;
    }

    /**
     * Check if this is a valid XML/HTML entity.
     *
     * @param out   The StringBuilder to write to.
     * @param in    Input String.
     * @param start Starting position
     * @return The new position or -1 if this entity in invalid.
     */
    private static int checkEntity(final StringBuilder out, final String in, final int start) {
        final int pos = Utils.readUntil(out, in, start, ';');
        if (pos < 0 || out.length() < 3) {
            return -1;
        }
        if (out.charAt(1) == '#') {
            if (out.charAt(2) == 'x' || out.charAt(2) == 'X') {
                if (out.length() < 4) {
                    return -1;
                }
                for (int i = 3; i < out.length(); i++) {
                    final char c = out.charAt(i);
                    if ((c < '0' || c > '9') && ((c < 'a' || c > 'f') && (c < 'A' || c > 'F'))) {
                        return -1;
                    }
                }
            } else {
                for (int i = 2; i < out.length(); i++) {
                    final char c = out.charAt(i);
                    if (c < '0' || c > '9') {
                        return -1;
                    }
                }
            }
            out.append(';');
        } else {
            for (int i = 1; i < out.length(); i++) {
                final char c = out.charAt(i);
                if (!Character.isLetterOrDigit(c)) {
                    return -1;
                }
            }
            out.append(';');
            return HTML.isEntity(out.toString()) ? pos : -1;
        }

        return pos;
    }

    /**
     * Recursively scans through the given line, taking care of any markdown
     * stuff.
     *
     * @param out   The StringBuilder to write to.
     * @param in    Input String.
     * @param start Start position.
     * @param token The matching Token (for e.g. '*')
     * @return The position of the matching Token or -1 if token was NONE or no
     * Token could be found.
     */
    private int recursiveEmitLine(final StringBuilder out, final String in, final int start, final MarkToken token) {
        int pos = start, a, b;
        final StringBuilder temp = new StringBuilder();
        while (pos < in.length()) {
            final MarkToken mt = this.getToken(in, pos);
            if (token != MarkToken.NONE
                    && (mt == token || token == MarkToken.EM_STAR && mt == MarkToken.STRONG_STAR || token == MarkToken.EM_UNDERSCORE
                    && mt == MarkToken.STRONG_UNDERSCORE)) {
                return pos;
            }

            switch (mt) {
                case IMAGE:
                case LINK:
                    temp.setLength(0);
                    b = this.checkLink(temp, in, pos, mt);
                    if (b > 0) {
                        out.append(temp);
                        pos = b;
                    } else {
                        out.append(in.charAt(pos));
                    }
                    break;
                case EM_STAR:
                case EM_UNDERSCORE:
                    temp.setLength(0);
                    b = this.recursiveEmitLine(temp, in, pos + 1, mt);
                    if (b > 0) {
                        this.config.decorator.openEmphasis(out);
                        out.append(temp);
                        this.config.decorator.closeEmphasis(out);
                        pos = b;
                    } else {
                        out.append(in.charAt(pos));
                    }
                    break;
                case STRONG_STAR:
                case STRONG_UNDERSCORE:
                    temp.setLength(0);
                    b = this.recursiveEmitLine(temp, in, pos + 2, mt);
                    if (b > 0) {
                        this.config.decorator.openStrong(out);
                        out.append(temp);
                        this.config.decorator.closeStrong(out);
                        pos = b + 1;
                    } else {
                        out.append(in.charAt(pos));
                    }
                    break;
                case SUPER:
                    temp.setLength(0);
                    b = this.recursiveEmitLine(temp, in, pos + 1, mt);
                    if (b > 0) {
                        this.config.decorator.openSuper(out);
                        out.append(temp);
                        this.config.decorator.closeSuper(out);
                        pos = b;
                    } else {
                        out.append(in.charAt(pos));
                    }
                    break;
                case CODE_SINGLE:
                case CODE_DOUBLE:
                    a = pos + (mt == MarkToken.CODE_DOUBLE ? 2 : 1);
                    b = this.findToken(in, a, mt);
                    if (b > 0) {
                        pos = b + (mt == MarkToken.CODE_DOUBLE ? 1 : 0);
                        while (a < b && in.charAt(a) == ' ') {
                            a++;
                        }
                        if (a < b) {
                            while (in.charAt(b - 1) == ' ') {
                                b--;
                            }
                            this.config.decorator.openCodeSpan(out);
                            Utils.appendCode(out, in, a, b);
                            this.config.decorator.closeCodeSpan(out);
                        }
                    } else {
                        out.append(in.charAt(pos));
                    }
                    break;
                case HTML:
                    temp.setLength(0);
                    b = this.checkHtml(temp, in, pos);
                    if (b > 0) {
                        out.append(temp);
                        pos = b;
                    } else {
                        out.append("&lt;");
                    }
                    break;
                case ENTITY:
                    temp.setLength(0);
                    b = checkEntity(temp, in, pos);
                    if (b > 0) {
                        out.append(temp);
                        pos = b;
                    } else {
                        out.append("&amp;");
                    }
                    break;
                case X_LINK_OPEN:
                    temp.setLength(0);
                    b = this.recursiveEmitLine(temp, in, pos + 2, MarkToken.X_LINK_CLOSE);
                    if (b > 0 && this.config.specialLinkEmitter != null) {
                        this.config.specialLinkEmitter.emitSpan(out, temp.toString());
                        pos = b + 1;
                    } else {
                        out.append(in.charAt(pos));
                    }
                    break;
                case X_COPY:
                    out.append("&copy;");
                    pos += 2;
                    break;
                case X_REG:
                    out.append("&reg;");
                    pos += 2;
                    break;
                case X_TRADE:
                    out.append("&trade;");
                    pos += 3;
                    break;
                case X_NDASH:
                    out.append("&ndash;");
                    pos++;
                    break;
                case X_MDASH:
                    out.append("&mdash;");
                    pos += 2;
                    break;
                case X_HELLIP:
                    out.append("&hellip;");
                    pos += 2;
                    break;
                case X_LAQUO:
                    out.append("&laquo;");
                    pos++;
                    break;
                case X_RAQUO:
                    out.append("&raquo;");
                    pos++;
                    break;
                case X_RDQUO:
                    out.append("&rdquo;");
                    break;
                case X_LDQUO:
                    out.append("&ldquo;");
                    break;
                case ESCAPE:
                    pos++;
                    //$FALL-THROUGH$
                default:
                    out.append(in.charAt(pos));
                    break;
            }
            pos++;
        }
        return -1;
    }

    /**
     * Turns every whitespace character into a space character.
     *
     * @param c Character to check
     * @return 32 is c was a whitespace, c otherwise
     */
    private static char whitespaceToSpace(final char c) {
        return Character.isWhitespace(c) ? ' ' : c;
    }

    /**
     * Check if there is any markdown Token.
     *
     * @param in  Input String.
     * @param pos Starting position.
     * @return The Token.
     */
    private MarkToken getToken(final String in, final int pos) {
        final char c0 = pos > 0 ? whitespaceToSpace(in.charAt(pos - 1)) : ' ';
        final char c = whitespaceToSpace(in.charAt(pos));
        final char c1 = pos + 1 < in.length() ? whitespaceToSpace(in.charAt(pos + 1)) : ' ';
        final char c2 = pos + 2 < in.length() ? whitespaceToSpace(in.charAt(pos + 2)) : ' ';
        final char c3 = pos + 3 < in.length() ? whitespaceToSpace(in.charAt(pos + 3)) : ' ';

        switch (c) {
            case '*':
                if (c1 == '*') {
                    return c0 != ' ' || c2 != ' ' ? MarkToken.STRONG_STAR : MarkToken.EM_STAR;
                }
                return c0 != ' ' || c1 != ' ' ? MarkToken.EM_STAR : MarkToken.NONE;
            case '_':
                if (c1 == '_') {
                    return c0 != ' ' || c2 != ' ' ? MarkToken.STRONG_UNDERSCORE : MarkToken.EM_UNDERSCORE;
                }
                if (this.useExtensions) {
                    return Character.isLetterOrDigit(c0) && c0 != '_' && Character.isLetterOrDigit(c1) ? MarkToken.NONE
                            : MarkToken.EM_UNDERSCORE;
                }
                return c0 != ' ' || c1 != ' ' ? MarkToken.EM_UNDERSCORE : MarkToken.NONE;
            case '!':
                if (c1 == '[') {
                    return MarkToken.IMAGE;
                }
                return MarkToken.NONE;
            case '[':
                if (this.useExtensions && c1 == '[') {
                    return MarkToken.X_LINK_OPEN;
                }
                return MarkToken.LINK;
            case ']':
                if (this.useExtensions && c1 == ']') {
                    return MarkToken.X_LINK_CLOSE;
                }
                return MarkToken.NONE;
            case '`':
                return c1 == '`' ? MarkToken.CODE_DOUBLE : MarkToken.CODE_SINGLE;
            case '\\':
                switch (c1) {
                    case '\\':
                    case '[':
                    case ']':
                    case '(':
                    case ')':
                    case '{':
                    case '}':
                    case '#':
                    case '"':
                    case '\'':
                    case '.':
                    case '>':
                    case '<':
                    case '*':
                    case '+':
                    case '-':
                    case '_':
                    case '!':
                    case '`':
                    case '~':
                    case '^':
                        return MarkToken.ESCAPE;
                    default:
                        return MarkToken.NONE;
                }
            case '<':
                if (this.useExtensions && c1 == '<') {
                    return MarkToken.X_LAQUO;
                }
                return MarkToken.HTML;
            case '&':
                return MarkToken.ENTITY;
            default:
                if (this.useExtensions) {
                    switch (c) {
                        case '-':
                            if (c1 == '-') {
                                return c2 == '-' ? MarkToken.X_MDASH : MarkToken.X_NDASH;
                            }
                            break;
                        case '^':
                            return c0 == '^' || c1 == '^' ? MarkToken.NONE : MarkToken.SUPER;
                        case '>':
                            if (c1 == '>') {
                                return MarkToken.X_RAQUO;
                            }
                            break;
                        case '.':
                            if (c1 == '.' && c2 == '.') {
                                return MarkToken.X_HELLIP;
                            }
                            break;
                        case '(':
                            if (c1 == 'C' && c2 == ')') {
                                return MarkToken.X_COPY;
                            }
                            if (c1 == 'R' && c2 == ')') {
                                return MarkToken.X_REG;
                            }
                            if (c1 == 'T' & c2 == 'M' & c3 == ')') {
                                return MarkToken.X_TRADE;
                            }
                            break;
                        case '"':
                            if (!Character.isLetterOrDigit(c0) && c1 != ' ') {
                                return MarkToken.X_LDQUO;
                            }
                            if (c0 != ' ' && !Character.isLetterOrDigit(c1)) {
                                return MarkToken.X_RDQUO;
                            }
                            break;
                    }
                }
                return MarkToken.NONE;
        }
    }

    /**
     * Writes a set of markdown lines into the StringBuilder.
     *
     * @param out   The StringBuilder to write to.
     * @param lines The lines to write.
     */
    private void emitMarkedLines(final StringBuilder out, final Line lines) {
        final StringBuilder in = new StringBuilder();
        Line line = lines;
        while (line != null) {
            if (!line.isEmpty) {
                in.append(line.value.substring(line.leading, line.value.length() - line.trailing));
                if (line.trailing >= 2) {
                    in.append("<br />");
                }
            }
            if (line.next != null) {
                in.append('\n');
            }
            line = line.next;
        }

        this.recursiveEmitLine(out, in.toString(), 0, MarkToken.NONE);
    }

    /**
     * Writes a set of raw lines into the StringBuilder.
     *
     * @param out   The StringBuilder to write to.
     * @param lines The lines to write.
     */
    private void emitRawLines(final StringBuilder out, final Line lines) {
        Line line = lines;
        if (this.config.safeMode) {
            final StringBuilder temp = new StringBuilder();
            while (line != null) {
                if (!line.isEmpty) {
                    temp.append(line.value);
                }
                temp.append('\n');
                line = line.next;
            }
            final String in = temp.toString();
            for (int pos = 0; pos < in.length(); pos++) {
                if (in.charAt(pos) == '<') {
                    temp.setLength(0);
                    final int t = Utils.readXML(temp, in, pos, this.config.safeMode);
                    if (t != -1) {
                        out.append(temp);
                        pos = t;
                    } else {
                        out.append(in.charAt(pos));
                    }
                } else {
                    out.append(in.charAt(pos));
                }
            }
        } else {
            while (line != null) {
                if (!line.isEmpty) {
                    out.append(line.value);
                }
                out.append('\n');
                line = line.next;
            }
        }
    }

    /**
     * Writes a code block into the StringBuilder.
     *
     * @param out   The StringBuilder to write to.
     * @param lines The lines to write.
     * @param meta  Meta information.
     */
    private void emitCodeLines(final StringBuilder out, final Line lines, final String meta, final boolean removeIndent) {
        Line line = lines;
        if (this.config.codeBlockEmitter != null) {
            final ArrayList<String> list = new ArrayList<String>();
            while (line != null) {
                if (line.isEmpty) {
                    list.add("");
                } else {
                    list.add(removeIndent ? line.value.substring(4) : line.value);
                }
                line = line.next;
            }
            this.config.codeBlockEmitter.emitBlock(out, list, meta);
        } else {
            while (line != null) {
                if (!line.isEmpty) {
                    for (int i = removeIndent ? 4 : 0; i < line.value.length(); i++) {
                        final char c;
                        switch (c = line.value.charAt(i)) {
                            case '&':
                                out.append("&amp;");
                                break;
                            case '<':
                                out.append("&lt;");
                                break;
                            case '>':
                                out.append("&gt;");
                                break;
                            default:
                                out.append(c);
                                break;
                        }
                    }
                }
                out.append('\n');
                line = line.next;
            }
        }
    }
}
